Skip to content

Conversation

@rich-iannone
Copy link
Member

@rich-iannone rich-iannone commented Aug 12, 2025

This PR adds the tab_footnote() method which allows you to add footnotes for different locations in the table. The method integrates footnote mark placement into the rendering pipeline for table headings, column labels, body cells, etc., and extends the internal data structures and options to support footnote config.

Here's an example of how this works in practice:

import polars as pl
from great_tables import GT, loc, md, html
from great_tables.data import towny

tbl_data = (
    pl.from_pandas(towny)
    .filter(pl.col('csd_type') == 'city')
    .select(['name', 'density_2021', 'population_2021', 'density_2016', 'population_2016'])
    .top_k(8, by='population_2021')
    .sort('population_2021', descending=True)
    .with_columns([
        ((pl.col('population_2021') - pl.col('population_2016')) / pl.col('population_2016') * 100).round(1).alias('pop_change'),
        ((pl.col('density_2021') - pl.col('density_2016')) / pl.col('density_2016') * 100).round(1).alias('density_change')
    ])
)

(
    GT(tbl_data, rowname_col='name')
    .tab_header(
        title=md('Ontario Cities: **2016 vs 2021 Census Data**'),
        subtitle='Comparison of population and density changes over 5 years.'
    )
    .tab_stubhead(label='Municipality')
    .tab_spanner(label='2016 Census', columns=['population_2016', 'density_2016'])
    .tab_spanner(label='2021 Census', columns=['population_2021', 'density_2021'])
    .tab_spanner(label='5-Year Change', columns=['pop_change', 'density_change'])
    .fmt_integer(columns=['population_2016', 'population_2021'])
    .fmt_number(columns=['density_2016', 'density_2021'], decimals=1)
    .fmt_number(columns=['pop_change', 'density_change'], decimals=1)
    .cols_label(
        population_2016='Population',
        density_2016='Density',
        population_2021='Population',
        density_2021='Density',
        pop_change='Population (%)',
        density_change='Density (%)'
    )
    .tab_footnote(
        footnote='Data taken from the census.',
        locations=loc.title()
    )
    .tab_footnote(
        footnote='Municipality names as they appear in the census.',
        locations=loc.stubhead()  # Test stubhead footnote
    )
    .tab_footnote(
        footnote='Population and density figures from the 2016 Census.',
        locations=loc.spanner_labels(ids=['2016 Census'])
    )
    .tab_footnote(
        footnote='Population and density figures from the 2021 Census.',
        locations=loc.spanner_labels(ids=['2021 Census'])
    )
    .tab_footnote(
        footnote='Percentage change calculated as ((2021 - 2016) / 2016) × 100.',
        locations=loc.spanner_labels(ids=['5-Year Change'])
    )
    .tab_footnote(
        footnote='Density measured in persons per square kilometer.',
        locations=loc.column_labels(columns=['density_2016', 'density_2021'])
    )
    .tab_footnote(
        footnote='Part of the Greater Toronto Area.',
        locations=loc.stub(rows=['Toronto', 'Brampton', 'Mississauga'])
    )
    .tab_footnote(
        footnote='Highest population growth in this dataset.',
        locations=loc.body(columns='pop_change', rows=3)
    )
    .tab_source_note(
        source_note = md("Data taken from the `towny` dataset (originally from the **gt** package).")
    )
)

And here is how it looks:

image

Fixes: #164

@codecov
Copy link

codecov bot commented Aug 12, 2025

Codecov Report

❌ Patch coverage is 91.89189% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.89%. Comparing base (de3580d) to head (9eda034).

Files with missing lines Patch % Lines
great_tables/_utils_render_html.py 92.03% 20 Missing ⚠️
great_tables/_footnotes.py 65.21% 8 Missing ⚠️
great_tables/_locations.py 98.66% 1 Missing ⚠️
great_tables/_text.py 92.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #763      +/-   ##
==========================================
+ Coverage   91.61%   91.89%   +0.27%     
==========================================
  Files          47       47              
  Lines        5773     6094     +321     
==========================================
+ Hits         5289     5600     +311     
- Misses        484      494      +10     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 03:54 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 03:58 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 13:41 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 19:58 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 20:00 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 21:06 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 August 12, 2025 22:10 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 15:11 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 15:46 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 16:55 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 17:05 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 17:08 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 9, 2025 17:23 Destroyed
@github-actions github-actions bot temporarily deployed to pr-763 September 22, 2025 19:43 Destroyed
@rich-iannone rich-iannone marked this pull request as ready for review September 23, 2025 18:42
@rich-iannone rich-iannone requested a review from machow October 6, 2025 17:58
@machow
Copy link
Collaborator

machow commented Jan 12, 2026

Can you resolve the conflicts on this?

@rich-iannone
Copy link
Member Author

@machow Will do, thanks!

@github-actions github-actions bot temporarily deployed to pr-763 January 13, 2026 00:51 Destroyed
@machow
Copy link
Collaborator

machow commented Jan 13, 2026

Code review

Found 3 issues:

  1. Duplicated locnum logic creates maintenance risk (Score: 90/100)

The footnote location ordering values (locnum) are defined in two places with inconsistent values:

  • In _locations.py: LocStubhead=2.5, LocSpannerLabels=3, LocColumnLabels=4, LocBody=5
  • In _utils_render_html.py: LocStubhead=3, LocSpannerLabels=4, LocColumnLabels=5, LocBody=6

While investigation shows the stored FootnoteInfo.locnum values are never actually read (both sorting paths recalculate via _get_locnum_for_footnote_location()), this duplication creates confusion and maintenance risk. Consider either removing the locnum field from FootnoteInfo or consolidating to a single source of truth.

(CLAUDE.md says "Use functools.partial - Reduce duplication for context-specific variants")

if isinstance(loc, LocTitle):
locnum = 1
elif isinstance(loc, LocSubTitle):
locnum = 2
elif isinstance(loc, LocStubhead) or isinstance(loc, LocStubheadLabel):
locnum = 2.5
else:
locnum = 6 # Default for footer-area locations

def _get_locnum_for_footnote_location(locname: loc.Loc | None) -> int | float:
"""Get the visual hierarchy order for footnote location ordering."""
if locname is None:
return 999
# Visual hierarchy mapping for footnote location ordering
if isinstance(locname, loc.LocTitle):
return 1
elif isinstance(locname, loc.LocSubTitle):
return 2
elif isinstance(locname, loc.LocStubhead):
return 3
elif isinstance(locname, loc.LocSpannerLabels):
return 4
elif isinstance(locname, loc.LocColumnLabels):
return 5
elif isinstance(locname, (loc.LocBody, loc.LocStub)):
return 6 # Same as data since stub and data cells are on the same row level
elif isinstance(locname, loc.LocRowGroups):
return 5
elif isinstance(locname, loc.LocSummary):
return 5.5
else:
return 999 # Default to 999 for unknown locations

  1. Missing location types in _get_locnum_for_footnote_location() (Score: 75/100)

Several location types are handled in _locations.py when setting footnotes, but are missing from the sorting function in _utils_render_html.py. These will default to locnum=999, causing footnotes to appear at the end instead of their proper position:

  • LocStubheadLabel - assigned 2.5 in _locations.py line 1210, missing from sorting function
  • LocColumnHeader - registered at line 1179, missing from sorting function
  • LocGrandSummary - assigned 5 in _locations.py line 1357, missing from sorting function
  • LocGrandSummaryStub - assigned 5 in _locations.py line 1322, missing from sorting function

Additionally, line 49 checks for LocSummary which is commented out (lines 646-651), while LocGrandSummary which is active is not handled.

def _get_locnum_for_footnote_location(locname: loc.Loc | None) -> int | float:
"""Get the visual hierarchy order for footnote location ordering."""
if locname is None:
return 999
# Visual hierarchy mapping for footnote location ordering
if isinstance(locname, loc.LocTitle):
return 1
elif isinstance(locname, loc.LocSubTitle):
return 2
elif isinstance(locname, loc.LocStubhead):
return 3
elif isinstance(locname, loc.LocSpannerLabels):
return 4
elif isinstance(locname, loc.LocColumnLabels):
return 5
elif isinstance(locname, (loc.LocBody, loc.LocStub)):
return 6 # Same as data since stub and data cells are on the same row level
elif isinstance(locname, loc.LocRowGroups):
return 5
elif isinstance(locname, loc.LocSummary):
return 5.5
else:
return 999 # Default to 999 for unknown locations

@set_style.register(LocGrandSummaryStub)
def _(
loc: (LocStub | LocGrandSummaryStub), data: GTData, style: list[Union[CellStyle, FootnoteEntry]]
) -> GTData:
styles, new_footnotes = footnotes_split_style_list(style)
# validate ----
for entry in styles:
entry._raise_if_requires_data(loc)
# TODO resolve
cells = resolve(loc, data)
new_styles = [StyleInfo(locname=loc, rownum=rownum, styles=styles) for rownum in cells]
# Handle footnotes
updated_footnotes = []
for row_pos in cells:
for footnote_info in new_footnotes:
updated_footnote = replace(footnote_info, locname=loc, rownum=row_pos, locnum=5)

@set_style.register(LocBody)
@set_style.register(LocGrandSummary)
def _(
loc: (LocBody | LocGrandSummary), data: GTData, style: list[Union[CellStyle, FootnoteEntry]]
) -> GTData:
positions: list[CellPos] = resolve(loc, data)
styles, new_footnotes = footnotes_split_style_list(style)
# evaluate any column expressions in styles
style_ready = [entry._evaluate_expressions(data._tbl_data) for entry in styles]
all_info: list[StyleInfo] = []
updated_footnotes: list[FootnoteInfo] = []
for col_pos in positions:
# Handle styles
row_styles = [entry._from_row(data._tbl_data, col_pos.row) for entry in style_ready]
crnt_info = StyleInfo(
locname=loc, colname=col_pos.colname, rownum=col_pos.row, styles=row_styles
)
all_info.append(crnt_info)
# Handle footnotes for this position
for footnote_info in new_footnotes:
updated_footnote = replace(
footnote_info, locname=loc, colname=col_pos.colname, rownum=col_pos.row, locnum=5

  1. FootnoteInfo.locnum field appears to be dead code (Score: 50/100)

The locnum field is set in FootnoteInfo objects throughout _locations.py, but both places that sort footnotes (_process_footnotes_for_display at line 1032 and _get_footnote_mark_string at line 1175) ignore the stored value and recalculate it by calling _get_locnum_for_footnote_location(fn_info.locname).

This makes the stored locnum field effectively dead code. Consider either using the stored value for sorting, or removing the field entirely.

@dataclass(frozen=True)
class FootnoteInfo:
locname: Loc | None = None
grpname: str | None = None
colname: str | None = None
locnum: int | float | None = None
rownum: int | None = None

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Copy link
Collaborator

@machow machow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind making the adjustments to locnum mentioned in the comment?

Notes

From digging a bit w/ claude code, here's a bulleted footnote mark path.

from great_tables import GT, loc
from great_tables._utils_render_html import (
    _get_footnote_mark_string,
    _process_footnotes_for_display,
)
import pandas as pd

# Create a simple table
df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})

# Apply the SAME footnote text to TWO different locations
gt = (
    GT(df)
    .tab_footnote(
        footnote="This is a shared note",
        locations=loc.body(columns="A", rows=[0])
    )
    .tab_footnote(
        footnote="This is a shared note",  # Same text!
        locations=loc.body(columns="B", rows=[1])
    )
)

# Build the data to resolve footnotes
built = gt._build_data("html")

# Show that both FootnoteInfo objects exist
print(f"Number of footnotes in _footnotes: {len(built._footnotes)}")
for i, fn in enumerate(built._footnotes):
    print(f"  [{i}] text={fn.footnotes[0]!r}, col={fn.colname}, row={fn.rownum}")

# Show that they get the SAME mark (deduplication by text)
print("\nMark assignments:")
for fn in built._footnotes:
    mark = _get_footnote_mark_string(built, fn)
    print(f"  text={fn.footnotes[0]!r} -> mark={mark!r}")

# Show the processed footnotes for footer (deduplicated)
footer_footnotes = _process_footnotes_for_display(built, built._footnotes)
print(f"\nFootnotes in footer (deduplicated): {len(footer_footnotes)}")
for fn in footer_footnotes:
    print(f"  mark={fn['mark']!r}, text={fn['text']!r}")

Footnote deduplication by text (same mark for identical text):

  • tab_footnote() creates a FootnoteInfo object for each call, stored in GT._footnotes
  • When rendering, _get_footnote_mark_string() (_utils_render_html.py:1155) determines marks:
    a. Collects all footnotes with their positions (locnum, rownum, colnum)
    b. Sorts by visual order (top-to-bottom, left-to-right)
    c. Builds unique_footnotes list, deduplicating by text content (lines 1200-1204)
    d. Looks up mark index via unique_footnotes.index(footnote_text) (line 1210)
  • For footer rendering, _process_footnotes_for_display() also deduplicates by text (line 1059: if footnote_text not in footnote_data)
  • Result: Multiple tab_footnote() calls with identical text → same mark in all locations, single entry in footer

updated_footnotes = []
for spanner_id in new_loc.ids:
for footnote_info in new_footnotes:
updated_footnote = replace(footnote_info, locname=loc, grpname=spanner_id, locnum=3)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From pairing, let's change blocks like this to not pass in locnum (which was hard-coded based off of this func dispatching on LocSpannerLabels). Let's do the following:

  • Remove locnum attribute from the FootnoteEntry class def
  • just use FootnoteEntry.locname.locnum (e.g. LocSpannerLabels.locnum)

Later may be useful to rename locname to loc?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the automated review I think locnum might be unused anyways?!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all the footnote updating logic across set_style functions is essentially the same (especially once manual locnum setting is removed)

Copy link
Collaborator

@machow machow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind making the adjustments to locnum mentioned in the comment?

Notes

From digging a bit w/ claude code, here's a bulleted footnote mark path.

from great_tables import GT, loc
from great_tables._utils_render_html import (
    _get_footnote_mark_string,
    _process_footnotes_for_display,
)
import pandas as pd

# Create a simple table
df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})

# Apply the SAME footnote text to TWO different locations
gt = (
    GT(df)
    .tab_footnote(
        footnote="This is a shared note",
        locations=loc.body(columns="A", rows=[0])
    )
    .tab_footnote(
        footnote="This is a shared note",  # Same text!
        locations=loc.body(columns="B", rows=[1])
    )
)

# Build the data to resolve footnotes
built = gt._build_data("html")

# Show that both FootnoteInfo objects exist
print(f"Number of footnotes in _footnotes: {len(built._footnotes)}")
for i, fn in enumerate(built._footnotes):
    print(f"  [{i}] text={fn.footnotes[0]!r}, col={fn.colname}, row={fn.rownum}")

# Show that they get the SAME mark (deduplication by text)
print("\nMark assignments:")
for fn in built._footnotes:
    mark = _get_footnote_mark_string(built, fn)
    print(f"  text={fn.footnotes[0]!r} -> mark={mark!r}")

# Show the processed footnotes for footer (deduplicated)
footer_footnotes = _process_footnotes_for_display(built, built._footnotes)
print(f"\nFootnotes in footer (deduplicated): {len(footer_footnotes)}")
for fn in footer_footnotes:
    print(f"  mark={fn['mark']!r}, text={fn['text']!r}")

Footnote deduplication by text (same mark for identical text):

  • tab_footnote() creates a FootnoteInfo object for each call, stored in GT._footnotes
  • When rendering, _get_footnote_mark_string() (_utils_render_html.py:1155) determines marks:
    a. Collects all footnotes with their positions (locnum, rownum, colnum)
    b. Sorts by visual order (top-to-bottom, left-to-right)
    c. Builds unique_footnotes list, deduplicating by text content (lines 1200-1204)
    d. Looks up mark index via unique_footnotes.index(footnote_text) (line 1210)
  • For footer rendering, _process_footnotes_for_display() also deduplicates by text (line 1059: if footnote_text not in footnote_data)
  • Result: Multiple tab_footnote() calls with identical text → same mark in all locations, single entry in footer

Copy link
Collaborator

@machow machow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind making the adjustments to locnum mentioned in the comment?

  • remove locnum from footnote class

Notes

From digging a bit w/ claude code, here's a bulleted footnote mark path.

from great_tables import GT, loc
from great_tables._utils_render_html import (
    _get_footnote_mark_string,
    _process_footnotes_for_display,
)
import pandas as pd

# Create a simple table
df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})

# Apply the SAME footnote text to TWO different locations
gt = (
    GT(df)
    .tab_footnote(
        footnote="This is a shared note",
        locations=loc.body(columns="A", rows=[0])
    )
    .tab_footnote(
        footnote="This is a shared note",  # Same text!
        locations=loc.body(columns="B", rows=[1])
    )
)

# Build the data to resolve footnotes
built = gt._build_data("html")

# Show that both FootnoteInfo objects exist
print(f"Number of footnotes in _footnotes: {len(built._footnotes)}")
for i, fn in enumerate(built._footnotes):
    print(f"  [{i}] text={fn.footnotes[0]!r}, col={fn.colname}, row={fn.rownum}")

# Show that they get the SAME mark (deduplication by text)
print("\nMark assignments:")
for fn in built._footnotes:
    mark = _get_footnote_mark_string(built, fn)
    print(f"  text={fn.footnotes[0]!r} -> mark={mark!r}")

# Show the processed footnotes for footer (deduplicated)
footer_footnotes = _process_footnotes_for_display(built, built._footnotes)
print(f"\nFootnotes in footer (deduplicated): {len(footer_footnotes)}")
for fn in footer_footnotes:
    print(f"  mark={fn['mark']!r}, text={fn['text']!r}")

Footnote deduplication by text (same mark for identical text):

  • tab_footnote() creates a FootnoteInfo object for each call, stored in GT._footnotes
  • When rendering, _get_footnote_mark_string() (_utils_render_html.py:1155) determines marks:
    a. Collects all footnotes with their positions (locnum, rownum, colnum)
    b. Sorts by visual order (top-to-bottom, left-to-right)
    c. Builds unique_footnotes list, deduplicating by text content (lines 1200-1204)
    d. Looks up mark index via unique_footnotes.index(footnote_text) (line 1210)
  • For footer rendering, _process_footnotes_for_display() also deduplicates by text (line 1059: if footnote_text not in footnote_data)
  • Result: Multiple tab_footnote() calls with identical text → same mark in all locations, single entry in footer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

epic: Implement footnotes

3 participants